Fragestellung: Wie können wir effizient vorhersagen, wer überlebt hat und wer nicht? Stimmt die Behauptung, dass Frauen und Kinder zuerst gerettet wurden? TODO: Lessons learned TODO: Beteiligung, wer hat was gemacht
Zuerst importieren wir notwendige Libraries und viele Elemente aus sklearn:
# Libraries:
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns
# Encoders:
pass
# Strategic imports:
pass
# Machine Learning Models:
pass
# Setups:
SEED = 42
np.random.seed(SEED)
sns.set()
Das Datenset wird geladen und erste Eindrücke gewonnen!
df_train = pd.read_csv('train.csv', index_col='PassengerId')
df_test = pd.read_csv('test.csv', index_col='PassengerId')
Zunächste betrachten wir die ersten Zeilen des Datensets:
df_train.head()
Außerdem lassen wir uns einige Statistiken anzeigen (nur von numerischen Features):
df_train.describe()
Das Datenset hat folgende Spalten:
df_train.info()
Die Spalten haben folgende Bedeutung:
PassengerId: Eindeutige Identifikationsnummer des Passagiers, wurde schon als Index der Datensets verwendet und
taucht deshalb hier nicht mehr auf.
Survived: Wer hat überlebt? Dies ist unsere Zielspalte (Label).
Pclass: Ticket Klasse (1, 2 oder 3) -> ordinale Skala. Dieses Feature sagt noch nichts darüber aus, wo die Zimmer
auf dem Schiff waren (weiter oben an Deck, oder tiefer im Schiff? Dies könnte anhand der Cabin erklärt werden.
Name: Name des Passagiers. Dieser enthält auch Titel wie "Mr" oder "Mrs". Bei Frauen kann so vielleicht zwischen
verheiratet ("Mrs") und unverheiratet ("Ms") unterschieden werden. Eventuell hat dies einen Einfluss auf die
Überlebenswahrscheinlichkeit.
Sex: Entweder "male" oder "female". Sollte vor Benutzung als 0/1 kodiert werden.
Age: Alter des Passagiers -> Rationale Skala, aber eventuell ist eine Einteilung in Kategorien sinnvoll?
SibSp: Anzahl der Geschwister (Siblings) und Ehepartner (Spouses).
Parch: Anzahl der Eltern (Parents) und Kinder (Children) des Passagiers.
Ticket: String oder Zahlenfolge, die die Ticketnummer des Passagiers angibt. Eine Ticket Nummer kann sich bei
verschiedenen Personen finden, die sich das Ticket also teilen.
Fare: Der Ticketpreis, welcher wahrscheinlich mit Deck (siehe Cabin) und Klasse (siehe Pclass) korreliert.
Scheinbar bezieht sich der Preis auf das Ticket. Die Erstellung eines Features "Preis/Person" scheint daher
sinnvoll.
Cabin: Kabinennummer (nur für sehr wenige Passagiere vorhanden). Der Buchstabe steht für das Deck, was eventuell
ein wichtiges Indiz für die Evakuierbarkeit des Passagiers zulässt.
Embarked: Hafen, an dem der Passagier an Bord gegangen ist (drei Möglichkeiten: Southampton, Cherbourg,
Queenstown). Eventuell korreliert dieser mit der Klasse (Reichtum der Bewohner an den Häfen?). Eventuell wird dieses
Feature aber auch weggelassen, da es keinen großen Einfluss auf die Überlebenschancen haben sollte.
Zunächst schauen wir uns die einzelnen Features genauer an. Dazu legen wir erst eine Arbeitskopie des Trainingsdatensatzes an und schauen uns außerdem einen Pairplot, sowie die Korrelationsmatrix an:
df = df_train.copy()
sns.pairplot(df, hue='Survived', kind='scatter', diag_kind='kde', diag_kws={'bw_adjust': 0.5})
plt.show()
sns.heatmap(df.corr(), cmap='seismic_r', annot=True, center=0)
plt.show()
Auffällig ist schon jetzt, dass die Überlebensrate am stärksten mit der Pclass und Fare korreliert, welche
ebenfalls beide korrelieren. Es ist zu beachten, dass kategorische Features (beispielsweise Sex) aktuell noch nicht
in der Korrelationsmatrix auftauchen (dazu müssten sie erst in eine Zahlenskala transformiert werden):
df.replace({'male': 0, 'female': 1}, inplace=True)
sns.heatmap(df.corr(), cmap='seismic_r', annot=True, center=0)
plt.show()
Offensichtlich ist die Korrelation mit dem Geschlecht am stärksten!
Ein wichtiger erster Schritt ist festzustellen, in welchem der Features Werte fehlen (NaN). Diese müssen dann
eventuell durch Imputation-Strategien durch sinnvolle Werte ersetzt werden.
def check_missing_values(df):
missing_sth = False
for name in df.columns:
nan_count = df[name].isnull().values.sum()
if nan_count > 0:
missing_sth = True
print(f'Column "{name}" is missing {nan_count} of {df.shape[0]} values')
if not missing_sth:
print('No column has missing data!')
check_missing_values(df)
Wir sehen, dass Age recht viele fehlende Werte hat, Embarked nur zwei Stück und das Cabin knapp 3/4 aller Werte
fehlen!
Für die Imputation kann man naiv den Median/Mittelwert aller vorhandenen Werte einsetzen. Wir können jedoch bessere
Ergebnisse erzielen, wenn wir uns anschauen, welche Werte am besten mit Age korrelieren und entsprechend auffüllen:
df.corr()['Age'].sort_values(ascending=False, key=abs)
In diesem Fall wäre dies das Pclass Feature. Wir könnten also prinzipiell Klassen-Mittelwerte oder Mediane für das
Alter berechnen und diese für die Imputation nutzen. Wir gehen jedoch einen Schritt weiter und schauen uns ein
neues Feature an, welches wir aus dem Namen generieren können (mehr dazu in Abschnitt 3.2.2):
df['Title'] = df['Name'].str.extract(pat='([A-Z][a-z]+\.)')
df['Title'].value_counts()
Viele Titel kommen nur selten oder ein einziges Mal vor. Wir fassen diese zusammen:
df['Title'][~df['Title'].isin(['Mr.', 'Miss.', 'Mrs.', 'Master.'])] = 'Misc.'
df['Title'].value_counts()
df.groupby(['Title'])['Age'].describe()
Wir sehen, dass die Title-Mediane deutliche Unterschiede zeigen, was unseren Ansatz bestätigt, den Titel zur
Imputation zu nutzen. Im Folgenden füllen wir die fehlenden Werte mit diesen Medianen auf:
df['Age'].fillna(df.groupby('Title')['Age'].transform('median'), inplace=True)
Um zu zeigen, dass unser Ansatz besser als Pclass-Mediane oder der Gesamt-Median ist, schauen wir uns diese hier an:
df.groupby(['Pclass'])['Age'].describe()
df['Age'].describe()
Für "Master" (kleine Jungen) wären diese Mediane beispielsweise deutlich schlechter gewesen als die von uns gewählten.
check_missing_values(df)
Die Features Cabin und Embarked haben noch fehlende Werte, wir werden jedoch beide vernachlässigen, was ein
Imputen überflüssig macht. In Cabin fehlen zu viele Werte (auch wenn die Deck-Nummer wahrscheinlich wertvolle
Informationen enthält) und Embarked sollte keinen großen Einfluss auf das Überleben haben.
Wir schreiben eine Imputer Funktion um später einfacher fehlende Werte zu ersetzen (Spalten, die nicht weiter benutzt
werden, werden gedroppt, hier: Cabin und Embarked):
def impute(df):
# Impute Age feature:
temp = df.copy()
temp['Title'] = temp['Name'].str.extract(pat='([A-Z][a-z]+\.)')
temp['Title'][~temp['Title'].isin(['Mr.', 'Miss.', 'Mrs.', 'Master.'])] = 'Misc.'
df['Age'].fillna(temp.groupby('Title')['Age'].transform('median'), inplace=True)
return df
df = df_train.copy()
df = impute(df)
Wir wollen nun einmal die einzelnen Features durchgehen und bewerten:
Pclass¶sns.countplot(x='Pclass', data=df, hue='Survived')
plt.show()
Die Ticket Klasse ist ein gutes Indiz für das Überleben der Passagiere. In der dritten Klasse sinken die Chancen das
Unglück zu überleben drastisch. Dieses Feature hat eine ordinale Skala und kann von uns so weiterverwendet werden.
Eventuell müssen wir noch skalieren (StandardScaler oder MinMaxScaler bieten sich an).
Name¶df['Name'].head(10)
Name enthält den Namen der Passagiere und hat dementsprechend eine Nominalskala. Wir haben stichprobenartig
untersucht, ob die Cross channel Passagiere (siehe: https://en.wikipedia.org/wiki/Passengers_of_the_Titanic) im
Datensatz vorkommen (wir haben außergewöhnliche/auffällige Namen genutzt):
cross_channel_samples = ['DeGrasse', 'Dyer-Edwardes', 'Lenox-Conyngham', 'Osborne', 'Remesch']
if df['Name'].str.contains('|'.join(cross_channel_samples)).any():
print('(Some) cross channel passengers are included!')
else:
print('No cross channel passengers were found!')
Obwohl wir den Namen selbst nicht benutzen können, ist es uns möglich ein interessantes Feature aus dem Datensatz zu extrahieren: den Titel der Person! Dieser wird immer groß geschrieben und endet in einem Punkt und wir können ihn mit einer Regular Expression erfassen (dieses Feature wurde schon in Abschnitt 3.1.1 zur Imputation benutzt und wird hier weiter erklärt):
df['Title'] = df['Name'].str.extract(pat='([A-Z][a-z]+\.)')
df['Title'].value_counts()
Sehr viele der selteneren Titel tauchen nur wenige Male auf und werden von uns zu Misc. (Miscellaneous =
Verschiedenes) zusammengefasst:
df['Title'][~df['Title'].isin(['Mr.', 'Miss.', 'Mrs.', 'Master.'])] = 'Misc.'
df[df['Title'].isin(['Misc.'])].head()
df['Title'].value_counts()
Schauen wir uns die Misc.-Titel ein wenig genauer im Hinblick auf die Überlebensrate an:
df[df['Title'] == 'Misc.'].sort_values(by='Survived')
Interessant ist, dass (wie bereits in 3.1.1 gezeigt), das Alter der Passagiere mit seltenen Titeln sehr hoch ist, was sich mit dem hohen Rang (Militär) oder geistlichen Würden (z.B. "Rev." für "Reverend"), sowie Adelsstand (z.B. "Countess") begründen lässt.
sns.countplot(x='Sex', data=df[df['Title'] == 'Misc.'], hue='Survived')
Außerdem kann man sehen, dass alle Frauen dieser Kategorie überlebt haben, von den Männern aber überdurchschnittlich viele nicht. Wir schätzen dieses Feature von daher als recht wichtig ein.
Zu guter Letzt wandeln wir die immer noch nominale Skala des Titels in 5 binäre Features um, die von unseren Algorithmen verwendet werden können:
df = pd.get_dummies(df, columns=['Title'])
df.info()
Sex¶sns.countplot(x='Sex', data=df, hue='Survived')
plt.show()
Das Geschlecht hat einen sehr starken Einfluss auf die Überlebenschancen! Aktuell hat dieses Feature eine Nominalskala ("male"/"female") und wird von uns in ein binäres Feature umgewandelt:
df.replace({'male': 0, 'female': 1}, inplace=True)
df['Sex'].value_counts()
sns.histplot(x='Age', data=df, hue='Survived')
Aus dem Histogramm lässt sich erkennen, dass junge Kinder viel höhere Überlebenschancen hatten als Erwachsene. Sehr auffällig ist der hohe Anteil von Ertrinkenden bei den ca. 30-jährigen. Entsprechend dieser Statistik haben wir uns für eine Kategorisierung der Daten entschieden. Ein paar Entscheidungskriterien:
5 war das Einschulungsalter zur damaligen Zeit.
Der älteste "Master" im Datenset ist 12 Jahre alt.
Volljährigkeit mit 18.
Peaks im Graph bei ca. 20 und ca. 30 rechtfertigen Abschnitte von 18-25, sowie von 35 bis 45.
Sehr alte Menschen (über 60) scheinen keine hohen Überlebenschancen zu haben.
df['AgeCat'] = pd.cut(df['Age'], bins=[0, 5, 12, 18, 25, 35, 60, np.inf], labels=[1, 2, 3, 4, 5, 6, 7]).astype(int)
sns.countplot(x='AgeCat', data=df, hue='Survived')
SibSp und Parch¶Diese Features beschreiben die Anzahl der Geschwister (Siblings) und Ehepartner (Spouses), sowie die Anzahl der Eltern (Parents) und Kinder (Children):
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
sns.countplot(x='SibSp', data=df, hue='Survived', ax=axes[0])
sns.countplot(x='Parch', data=df, hue='Survived', ax=axes[1])
Beide Features zeigen einen sehr ähnlichen Zusammenhang zur Überlebensrate und werden von uns von daher zur Familiengröße ´FamilySize´ zusammengefasst:
df['FamilySize'] = df['SibSp'] + df['Parch'] + 1 # +1: die Person selbst wird mit eingerechnet!
df['FamilySize'].value_counts()
Eine interessante Entdeckung war ein Geschwisterpaar (SibSp=1), welches ohne Eltern (Parch=0) auf Reisen waren.
Beide (12 und 14 Jahre) haben überlebt!
df[df['Ticket']=='2651']
Ticket¶Die Ticketnummer kann von uns nicht direkt verwendet werden, da es scheinbar kein eindeutiges System für die Zahlen
und Buchstaben gibt. Man kann weder die Kabine, noch das Deck ableiten und es scheint, dass unterschiedliche
Ausgabestellen andere Konventionen verwenden. Was wir jedoch tun können, ist die Ticketnummer zu verwenden um
Passagiere zusammenzufassen, die zusammen gereist sind. Wir führen deshalb das neue Feature GroupSize ein welches
nicht unähnlich zur FamilySize ist (allerdings können auch Freunde zusammen reisen und Familien können mehrere
Tickets nutzen, es besteht also ein Mehrwert dieses Features):
df['GroupSize'] = df['Ticket'].map(df['Ticket'].value_counts())
df['GroupSize'].value_counts()
Im Folgenden zeigen wir die ersten drei Reisegruppen mit dem Maximum von 7 Personen in GroupSize:
Ticket=CA. 2434: Familie Sage mit 11 Personen, die also mehrere Tickets besaßen. Alle 7 von diesem Ticket starben.
Ticket=347082: Familie Andersson mit 7 Personen, alle mit diesem Ticket. Alle starben.
Ticket=1601: Eine asiatische Reisegruppe, die nicht verwandt war, von denen 5 überlebten.
Für die erste Gruppe gibt es leider keine gute Möglichkeit die anderen 4 Familienmitglieder (auf mindestens einem weiteren Ticket) ausfindig zu machen um zu überprüfen ob diese überlebt haben.
columns = ['Name', 'Ticket', 'GroupSize', 'FamilySize', 'Survived']
df[columns].sort_values(by=['GroupSize', 'Ticket'], ascending=False).head(21)
Fare¶df.groupby(['Pclass'])['Fare'].describe()
Der Ticketpreis variiert sehr stark bis hin zu 512$ in der 1. Klasse. Auffällig ist auch, dass scheinbar Leute umsonst mitgefahren sind (0$ Minimum in allen 3 Klassen). Ein weiteres Problem ist, dass die Preise pro Ticket und nicht pro Person angegeben sind. Dies korrigieren wir im Folgenden:
df['PersonPerTicket'] = df['Ticket'].map(df['Ticket'].value_counts())
df['FarePerPerson'] = df['Fare'] / df['PersonPerTicket']
Um weitere Aussagen machen zu können schauen wir auf ein Histogramm:
sns.histplot(x='FarePerPerson', data=df, hue='Survived')
plt.show()
Die extrem hohen Preise scheinen sehr starke Ausreißer zu sein, was das Lesen des Plots erschwert. Wir beschränken
deshalb die Plotting-Range in x. Zusätzlich schneiden beschränken wir auch y um höhere Preise besser untersuchen
zu können:
sns.histplot(x='FarePerPerson', data=df, hue='Survived')
plt.xlim(0, 150)
plt.ylim(0, 50)
plt.show()
Auffällig viele Passagiere in den niedrigen Preisklassen haben die Reise nicht überlebt. Da der Preis mit der Klasse
korrelieren sollte, ist dies jedoch nicht verwunderlich. Jedoch scheinen Leute mit sehr hohen Ticketpreisen sehr gute
Chancen zu haben. Von den Leuten, die 0$ gezahlt haben verunglückten die meisten! Aufgrund dieser Überlegungen legen
wir auch für Fare Kategorien fest, die wir in einem neuen Feature FareCat speichern.
df['FareCat'] = pd.cut(df['FarePerPerson'], bins=[-1, 1, 10, 20, 30, 50, np.inf], labels=[1, 2, 3, 4, 5, 6]).astype(int)
sns.countplot(x='FareCat', data=df, hue='Survived')
plt.show()
df.groupby(['Survived'])['FareCat'].value_counts()
Unsere Einteilung zeigt sogar, das nur eine Person mit einem Preis von 0$ überlebt hat!
df[(df['FareCat'] == 1) & (df['Survived'] == 1)]
ANMERKUNG: Wenn nicht alle Passagiere eines Tickets im Trainingsset sind (z.B. teilweise im Testset oder gar nicht vorhanden), dann kann von der ermittelten Zahl der Personen pro Ticket nicht exakt auf den pro-Kopf-Preis geschlossen werden. Dies wird von uns hier jedoch vernachlässigt.
Cabin¶Cabin: Kabinennummer (nur für sehr wenige Passagiere vorhanden). Der Buchstabe steht für das Deck, was eventuell
ein wichtiges Indiz für die Evakuierbarkeit des Passagiers zulässt.
Embarked¶Embarked: Hafen, an dem der Passagier an Bord gegangen ist (drei Möglichkeiten: Southampton, Cherbourg,
Queenstown). Eventuell korreliert dieser mit der Klasse (Reichtum der Bewohner an den Häfen?). Eventuell wird dieses
Feature aber auch weggelassen, da es keinen großen Einfluss auf die Überlebenschancen haben sollte.
Im Folgenden fassen wir unsere Überlegungen über die ursprünglichen Features des Datensets in einer Transformer-Klasse zusammen, welche uns vorhandene Features bei Bedarf umformt, neue Features erstellt und zu guter Letzt Features droppt, die wir nicht für die Modelle benötigen: FUNCTION STARTS HERE:
def feature_engineer(df):
# Create new `Title` feature and create a new numeric feature for each different title:
df['Title'] = df['Name'].str.extract(pat='([A-Z][a-z]+\.)')
df['Title'][~df['Title'].isin(['Mr.', 'Miss.', 'Mrs.', 'Master.'])] = 'Misc.'
df = pd.get_dummies(df, columns=['Title'])
# Replace `Sex` string entries with 0/1:
df.replace({'male': 0, 'female': 1}, inplace=True)
# Categorize `Age` Feature:
df['AgeCat'] = pd.cut(df['Age'], bins=[0, 5, 12, 18, 25, 35, 60, np.inf], labels=[1, 2, 3, 4, 5, 6, 7]).astype(int)
# Create new feature `FamilySize`:
df['FamilySize'] = df['SibSp'] + df['Parch'] + 1 # +1: Person itself
# Create new feature `GroupSize` (not necessarily relatives):
df['GroupSize'] = df['Ticket'].map(df['Ticket'].value_counts())
# Categorize `Fare` Feature:
df['PersonPerTicket'] = df['Ticket'].map(df['Ticket'].value_counts())
df['FarePerPerson'] = df['Fare'] / df['PersonPerTicket']
bins, labels = [-1, 1, 10, 20, 30, 50, np.inf], [1, 2, 3, 4, 5, 6]
df['FareCat'] = pd.cut(df['FarePerPerson'], bins=bins, labels=labels).astype(int)
# Drop all non-used features:
drop = ['Age', 'Name', 'Cabin', 'Embarked', 'SibSp', 'Parch', 'Ticket', 'PersonPerTicket', 'Fare', 'FarePerPerson']
df.drop(drop, axis=1, inplace=True)
return(df)
df = df_train.copy()
df = impute(df)
df = feature_engineer(df)
df.info()
df.describe()
########################################################################################################################
# Zielwerte abtrennen
survival_target = np.array(df["Survived"]).copy()
# reshape um es später an Algorithmen weiterzugeben.# droppen von survived
#survival_target = np.reshape(survival_target,(survival_target.shape[0],1) )
df.drop(['Survived'],
axis=1, inplace=True)
feature_matrix = df
print(df.columns)
# Scaling mit den Features
from sklearn.preprocessing import MinMaxScaler
scaler = MinMaxScaler()
scaled_features = scaler.fit(feature_matrix)
scaled_features = scaler.transform(feature_matrix)
# split train/test: Auftrennen in Trainings und Validationsmenge(test=validation)
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
scaled_features, survival_target, test_size=0.2, random_state=0)
print(X_train)
print(X_train.shape)
################################
# PCA um Features rauszuwerfen
# eventuell nicht notwendig, da wenige Daten
################################
# erstes Modell (fit, transform, predict (mit test und train?) und Auswertung(Score?)
from sklearn.tree import DecisionTreeClassifier, plot_tree
clf = DecisionTreeClassifier(max_depth=4)
#y_train = y_train.flatten()
print(X_train.shape)
print(y_train.shape)
model = clf.fit(X_train, y_train)
print("score train: ", model.score(X_train, y_train))
print("score test: ", model.score(X_test, y_test))
with plt.style.context('classic'):
plt.figure(figsize=(16,10))
plot_tree(model)
plt.show()
# mit cross validation (Test im Trainings-Set):
from sklearn.model_selection import cross_val_score
clf2 = DecisionTreeClassifier(max_depth=4)
scores = cross_val_score(clf2, X_train, y_train, cv=5)
print("Score mit cross_val: ", scores)
print("Score cross_val Mittelwert",scores.mean())
#random forest????
# eventuell grid search für verschiedene parameter im DecisionTree
from sklearn.model_selection import GridSearchCV
#Parameter für DesicionTree Gridsearch: unterschiedliche Tiefen und Entscheidungskriterien
param_grid = {"max_depth":[3, 4, 5, 6, 7, 8, 10, 20], "criterion":["gini", "entropy"]}
decision_tree = DecisionTreeClassifier()
grid_search = GridSearchCV(decision_tree, param_grid, return_train_score=True, cv=6)
grid_search.fit(X_train, y_train)
print(grid_search)
print('Bester Parameter Gridsearch: ', grid_search.best_params_)
cvres = grid_search.cv_results_
#print(cvres)
for mean_score, params in zip(cvres["mean_test_score"], cvres["params"]):
print((mean_score), params)
# Bayes
from sklearn.naive_bayes import CategoricalNB
bayes_clf = CategoricalNB()
model = bayes_clf.fit(X_train, y_train)
print("score train Bayes: ", model.score(X_train, np.ravel(y_train)))
print("score test Bayes: ", model.score(X_test, np.ravel(y_test)))
# Knearest
from sklearn.neighbors import KNeighborsClassifier
KN_clf = KNeighborsClassifier(weights = 'distance')
model = KN_clf.fit(X_train, y_train)
print("score train KN_clf (weights = distance): ", model.score(X_train, y_train))
print("score test KN_clf (weights = distance): ", model.score(X_test, y_test))
KN_clf = KNeighborsClassifier(weights = 'uniform')
model = KN_clf.fit(X_train, y_train)
print("score train KN_clf (weights = uniform): ", model.score(X_train, y_train))
print("score test KN_clf (weights = uniform): ", model.score(X_test, y_test))